Skip to content

feat(web): dock the state-grouped fleet beside the conductor's chat - #325

Merged
saucam merged 1 commit into
mainfrom
feat/p5-2-fleet-lanes
Sep 6, 2026
Merged

feat(web): dock the state-grouped fleet beside the conductor's chat#325
saucam merged 1 commit into
mainfrom
feat/p5-2-fleet-lanes

Conversation

@saucam

@saucam saucam commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

P5.2 — the centerpiece of the front-doors work (conductor-frontends-design.md §4, §12). Focusing the conductor now renders its ordinary chat with the fleet board docked in a right rail.

Shape

Classification lives in lib/fleet-lanes.ts as pure functions — the status vocabulary (§6) and triage lanes (§4) are the design decision here, so they belong somewhere a test can reach without a reactive root. FleetRail.tsx renders over state/fleet.ts (#324).

Two rules from §6 that are easy to get wrong

awaiting outranks working. A node needing a human must never hide behind one that's merely busy, so it's checked before the task's own status and sorts first. A worker wedged on an approval reads as awaiting even while its task still says running — the dispatch status describes the queue, the session status describes the agent, and it's the agent that's stuck on a person.

disconnected is not failed. A dropped runner is a transport event the dispatcher reconciles. Colouring it red trains the operator to ignore red, so it renders quiet grey. Relatedly: a claimed spawn that hasn't created its worker yet is queued, not disconnected — otherwise every healthy dispatch flashes a fault for a moment.

Two deliberate narrowings

"Ready to review" means a finished task with a digest — something a human can actually read. §4 envisages this lane eventually carrying worktree diffs and sequenced merge (P5.4); until then a review lane holding nothing readable would be worse than none.

Empty lanes are omitted. Four permanent headers over one running task is chrome, not information.

§3.B — the rail links out, never embeds

Every node is a real session, so clicking one focuses its ordinary cockpit; "take over" is the existing attach flow reached from a node. A task with no session to drill into renders as a plain row rather than a button that would do nothing.

SessionBody is extracted so the conductor branch docks a rail beside the same cockpit every agent gets, rather than reimplementing it.

Verified against a live conductor

Not just fixtures — I drove a real dispatch and classified the resulting board:

needs-you count: 2

Needs you  (2)
   [blocked     ] spawn cff8e6c5    scout  err:reclaimed: stale claim
   [blocked     ] spawn c0234348    scout  err:reclaimed: stale claim
Ready to review  (3)
   [done        ] spawn 1c261b20    scout  digest:task 1c261b20 (spawn/scout) in …
   [done        ] spawn 17960316    scout  digest:…
   [done        ] spawn 14bac189    scout  digest:…

The two tasks blocked by the old daemon-collision bug (#319) land in Needs you with their real error; the three successful dispatches land in Ready to review; needsYouCount matches agg.blockedTasks.

That run also surfaced something worth knowing: a finished task has no worker session — the dispatcher tears it down after the digest — so the session join returns null and the row falls back to the task id. Handled, and it's why done is classified before the null-session branch.

Verification

16 lane tests, 215 web tests total, typecheck / lint / build clean.

Component rendering itself still isn't locally testable (the Node <20.19 jsdom gap), but CI runs those suites.

Next

P5.1's remaining piece is the Conductor ⇄ Sessions toggle (§3.A); P5.3 is the cross-session "Needs you" inbox, which this rail's needsYouCount already feeds.

🤖 Generated with Claude Code

P5.2, the centerpiece of the front-doors work
(docs/conductor-frontends-design.md §4, §12). Focusing the conductor now
renders its ordinary chat with the fleet board docked in a right rail.

Classification lives in `lib/fleet-lanes.ts` as pure functions — the
status vocabulary (§6) and the triage lanes (§4) ARE the design decision
here, so they belong somewhere a test can reach without a reactive root.
`FleetRail.tsx` is the renderer over `state/fleet.ts`.

Two rules from §6 drive the vocabulary and are easy to get wrong:

**`awaiting` outranks `working`.** A node that needs a human must never
hide behind one that is merely busy, so it is checked before the task's
own status and sorts first. A worker wedged on an approval reads as
`awaiting` even while its task still says `running` — the dispatch status
describes the QUEUE, the session status describes the AGENT, and it is
the agent that is stuck on a person.

**`disconnected` is not `failed`.** A dropped runner is a transport
event; the dispatcher reconciles it. Colouring it red trains the operator
to ignore red, so it renders quiet grey. A claimed spawn that has not
created its worker yet is `queued`, not `disconnected` — otherwise every
healthy dispatch flashes a fault for a moment.

"Ready to review" is deliberately narrow: a finished task that produced a
digest, i.e. something a human can actually read. §4 envisages this lane
eventually carrying worktree diffs and sequenced merge (P5.4); until
then, a review lane holding nothing readable would be worse than none.
Empty lanes are omitted — four permanent headers over one running task is
chrome, not information.

Per §3.B the rail links out and never embeds a second copy of a session's
chat: every node is a real session, so clicking one focuses its ordinary
cockpit. "Take over" is the existing attach flow reached from a node. A
task with no session to drill into renders as a plain row rather than a
button that would do nothing. `SessionBody` is extracted so the conductor
branch docks a rail beside the SAME cockpit every agent gets, rather than
reimplementing it.

Verified against a live conductor, not just fixtures: a real board of 5
tasks classified correctly — the two tasks blocked by the old
daemon-collision bug landed in "Needs you" with their real error, the
three successful dispatches in "Ready to review" with digests, and
needsYouCount matched agg.blockedTasks. That run also confirmed a
finished task has NO worker session (the dispatcher tears it down after
the digest), so the session join returns null and the row falls back to
the task id — handled, and the reason `done` is classified before the
null-session branch.

16 lane tests; 215 web tests total; typecheck, lint and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saucam
saucam merged commit 98967db into main Sep 6, 2026
4 checks passed
saucam added a commit that referenced this pull request Sep 6, 2026
conductor-design §3 specifies that the fleet READ verbs — fleet_list,
fleet_find, fleet_summary, fleet_recall, fleet_tasks, machine_map — "run
silently". They did not. `isSafeTool` knew the memory and blackboard
mounts but not the fleet, so in guarded mode every one of them raised an
approval prompt.

Found by driving a real conductor rather than reading the code: asking it
to resolve a session reference produced an approval request for
`fleet_find`, which is a read. An assistant that asks permission to look
something up is not an assistant, and this is the first friction anyone
meets in the new conductor pane (#325).

The daemon already put these verbs in the provider's `allowedTools`, so
the SDK warned it would auto-approve them — but codeoid's own gate is
consulted independently and did not recognise them, which is why the two
disagreed.

Follows the established shape exactly: match the namespace prefix, then
require the suffix to be a known read verb. Never a bare prefix match — an
over-broad match here is a prompt bypass, which is why a look-alike
segment (`mcp__evil_codeoid_fleet__…`) is asserted to gain nothing.

The read list is `FLEET_READ_TOOLS` from the shared protocol package, so
the send half cannot leak in by someone editing one of two copies. Send
verbs remain hard-gated BEFORE this function is consulted
(`isFleetSendTool` in Session#shouldAutoApprove, checked ahead of any mode
logic); this is defence in depth, not the fence.

Verified live, both directions. With the fix, fleet_list / fleet_find /
machine_map go straight to `executing` with zero prompts; fleet_spawn
still lands in `waiting_confirmation` and waits for the owner. The R3
invariant is intact.

Also lists the bare (non-`mcp__`) namespacing so a mounted fleet (#245)
does not silently regress to prompting on every read.

10 tool-safety tests; 2452 daemon tests; typecheck and lint clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
saucam added a commit that referenced this pull request Sep 7, 2026
* fix(conductor): let the fleet read surface run without prompting

conductor-design §3 specifies that the fleet READ verbs — fleet_list,
fleet_find, fleet_summary, fleet_recall, fleet_tasks, machine_map — "run
silently". They did not. `isSafeTool` knew the memory and blackboard
mounts but not the fleet, so in guarded mode every one of them raised an
approval prompt.

Found by driving a real conductor rather than reading the code: asking it
to resolve a session reference produced an approval request for
`fleet_find`, which is a read. An assistant that asks permission to look
something up is not an assistant, and this is the first friction anyone
meets in the new conductor pane (#325).

The daemon already put these verbs in the provider's `allowedTools`, so
the SDK warned it would auto-approve them — but codeoid's own gate is
consulted independently and did not recognise them, which is why the two
disagreed.

Follows the established shape exactly: match the namespace prefix, then
require the suffix to be a known read verb. Never a bare prefix match — an
over-broad match here is a prompt bypass, which is why a look-alike
segment (`mcp__evil_codeoid_fleet__…`) is asserted to gain nothing.

The read list is `FLEET_READ_TOOLS` from the shared protocol package, so
the send half cannot leak in by someone editing one of two copies. Send
verbs remain hard-gated BEFORE this function is consulted
(`isFleetSendTool` in Session#shouldAutoApprove, checked ahead of any mode
logic); this is defence in depth, not the fence.

Verified live, both directions. With the fix, fleet_list / fleet_find /
machine_map go straight to `executing` with zero prompts; fleet_spawn
still lands in `waiting_confirmation` and waits for the owner. The R3
invariant is intact.

Also lists the bare (non-`mcp__`) namespacing so a mounted fleet (#245)
does not silently regress to prompting on every read.

10 tool-safety tests; 2452 daemon tests; typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): Conductor ⇄ Sessions toggle

Completes P5.1 (docs/conductor-frontends-design.md §3.A). Two co-equal
top-level homes with one control in the status bar.

§3 is the constraint the whole feature rests on: the conductor is a LENS
over the same sessions, never a wall, and there must be no state a user
can get stuck in. So this is a navigation preference, not a mode —
switching home changes which session you are looking at and nothing else,
and every session stays reachable from the list in both homes.

**Sessions stays the default, even once a conductor exists.** Silently
relocating someone's home the first time they spawn a conductor is
exactly the "trapped in an orchestrated mode" feeling §3 exists to
prevent, and a user who wants the conductor is one click away — then
remembered. This settles the "default home" question left open in §13.

The resolution rules live in `lib/home.ts` as pure functions, since which
session a home lands on is the decision worth testing:

- Conductor home focuses the conductor, or does NOTHING when there is
  none — a normal state, not an error. `null` means "leave focus alone",
  deliberately distinct from "focus nothing", so re-selecting a home you
  are already on does not reset scroll for nothing.
- Sessions home acts only when you are actually sitting on the conductor.
  Otherwise you are already somewhere in Sessions and moving you would be
  the surprise this design avoids.
- It returns you to the session you came from, falling back to the first
  ordinary session when that one was destroyed.

Workers are never a landing target, on either path. They are disposable
and die with their task, so landing on one is landing somewhere about to
disappear — and a worker can legitimately be the last thing you looked
at, having drilled into it from the fleet rail. A test caught that: the
remembered-session path originally excluded only conductors.

Acting on the choice is an effect rather than click handling, so the two
stay consistent when the population changes underneath — the conductor
being created while Conductor home is already selected, for instance.

The preference persists via the existing layout store, validated on read
rather than cast: a stored value from a future build must fall back, not
select a home that does not exist. The remembered session is deliberately
NOT persisted — it is a within-visit convenience, and an id from days ago
is likelier to name a destroyed session than to be useful.

11 home tests; 210 web tests; typecheck, lint and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(web): name the ordinary-session predicate, keep its fail-safe

Review follow-up on #329 (Oracle, both comments).

The feedback asked for an explicit role taxonomy instead of
`s.role === undefined`, suggesting `role !== "conductor" && role !==
"worker"`. Taking the first half and declining the second.

**Taken: name it.** The check was an inline lambda in `home.ts` and a
duplicated inline test in `HomeToggle.tsx`. Both now call an exported
`isOrdinarySession`, so the taxonomy has a name and one definition.

**Declined: the negative form**, because it inverts the safety property.
`SessionInfo.role` is documented as "Absent = normal session", and the
protocol deliberately anticipates roles this client has not heard of —
`session.create` types its role as an open string precisely "so a future
role from a newer client still type-checks on the wire". The two forms
therefore differ exactly when a new role appears:

  role === undefined           → an unknown role is NOT ordinary (excluded)
  role !== "conductor" && ...  → an unknown role IS ordinary     (included)

The negative form reads as more explicit and is the more dangerous of the
two: it silently opts every future session kind into being a focus
target. Workers are excluded here because they vanish with their task;
inheriting that risk for kinds we know nothing about is the wrong
default. An unknown role now stays excluded until somebody adds it here
deliberately.

Pinned by two tests — one on the predicate, one through `homeTarget` —
that a session with an unrecognised role is never a landing target.

14 home tests; 213 web tests; typecheck, lint and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants